单元测试 - 工具
工欲善其事,必先利其器。写单元测试,需要三类工具
- 测试平台:JUnit、TestNG
- Mock框架:Mockito、MockK
- 断言框架:AssertJ、Assertk
当开发语言为kotlin时,推荐JUnit5 + MockK + Assertk的组合。
JUnit
测试框架承载单元测试运行的环境,基础技术。一般会提到JUnit和TestNG,Spring默认集成了JUnit5,后者由于没有具体使用过,不大好作评论,但是网上文章搜了一圈,相比于JUnit5,TestNG并没有看出什么优势,具有差不多的功能,但是需要写xml。处于好奇看了下两者的发布时间,JUnit5诞生的日期、更新的频繁程度都较高,所以倾向使用JUnit5,至于JUnit4,现在已经过时了。
框架 | 首版发布日期 | 首个正式版发布日期 | 最近一个正式版发布日期 | 更新频率 |
---|---|---|---|---|
JUnit4 | 2014-7-29 | 2014-7-29 | 2021-2-14 | 一年左右 |
JUnit5 | alpha版2016-2-1 | 2017-10-03 | 2021-11-29 | 一两个月一次 |
TestNG | 2010往前 | 2010往前 | 2022-1-3 | 一年左右 |
概览
注意区分它有4、5两个版本,后者为最新版,也是极力推广的版本,功能丰富了不少。组成如下
JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage
JUnit Platform:在JVM中启动测试的基础;同时提供开发测试引擎的API
JUnit Jupiter:是写单元测试的编程模型、扩展模型的结合;提供跑基于juipter测试的测试引擎
JUnit Vintage:提供在当前平台跑JUnit3和JUnit4的测试引擎
JUnit5最低支持JDK8
有关JUnit的全部功能,参考官方手册,建议从头到尾看一遍,了解一下有哪些型号的锤子。
基本概念
掌握三个基本概念
测试类
如下三种类可以被称作静态类,并且它们必须至少包含一个测试方法
- 顶级类
- 静态内部类
- 被@Nested注解的非静态内部类
测试方法
被@Test,@RepeatedTest, @ParameterizedTest, @TestFactory, or @TestTemplate注解的实例方法
生命周期方法
被@BeforeAll,@AfterAll, @BeforeEach, or @AfterEach注解的方法
注意事项
- 除@TestFactory注解的方法外,测试方法不能有返回值
- 测试类、测试方法不必是public的,但也不能是private的
- 对于Java来说,JUnit建议省略掉类和方法的public关键字
注解和功能解释
JUnit有21个以上的注解,我们挑选其中最常用的看
注解 | 说明 |
---|---|
@Test | 标记一个测试方法 |
@ParameterizedTest | 参数化测试,与数据源注解协同使用 |
@RepeatedTest | 重复执行的方法,即一个方法被执行多次 |
@DisplayName | 显示在测试报告中的名称 注:部分注解有name属性,也可以指定 |
@Nested | 表明被注解的类是非静态的嵌套类;主要用来分组 |
@Tag | 标签,用于过滤 和TestNG中的组概念类似 和JUnit4中的分类概念类似 |
@BeforeAll @AfterAll @BeforeEach @AfterEach |
生命周期方法 |
@Timeout | 方法执行的超时时间,超时则报错 |
@ExtendWith | 声明式扩展 |
@RegisterExtension | 编程式扩展 |
一个例子
使用Kotlin编写的两个工具测试方法
1 | /** |
对应的单元测试如下
1 |
|
运行整个测试类,能够得到如下输出
1 | 全局开始 |
上面的例子可以总结出几个问题,我们一个一个看
- 如何启动那个测试类?可能在IEAD中有启动按钮,但是如果只是给了一个类文件,我们要如何启动呢?在CI构建时要如何启动呢?
- 为什么要用嵌套类?
- @BeforeAll的使用看起来很不方便?
- 测试case的生命周期是怎样的?
- 参数化测试,还支持别的设置参数的方式吗?
启动
三种启动方式
Console Launcher
提供一个可执行文件 junit-platform-console-standalone-1.8.2.jar ,用如下命令执行测试
1
java -jar junit-platform-console-standalone-1.8.2.jar <额外选项>
这种场景还没用过
IDEA插件 —— 开发最常用
到IDEA的插件市场搜索junit,安装插件就能直接测试(可以看到,junit插件是软件绑定的,默认已经安装了,甚至没有卸载选项)
此时写的测试类和方法上就会有运行按钮
gradle插件 —— CI最常用
使用gradle构建时需要添加junit插件。同样,gradle已经将JUnit添加到test任务的默认支持工具中,同样支持的还有JUnit4、TestNG。详情参考gradle的测试文档,可配置的参数比较多,一个较为简单的配置如下
1
2
3
4
5
6
7
8
9
10
11
12test {
// 使用JUnit5进行测试
useJUnitPlatform()
// 测试线程数:2
maxParallelForks(2)
// 日志配置
testLogging {
// level=LIFECYCLE的配置项
events "passed", "skipped", "failed"
exceptionFormat "full"
}
}
测试生命周期
一个测试类启动后,生命周期从上到下为(以上面的例子为例)
- 测试类ExtensionMethodTest加载,执行被@BeforeAll注解的静态方法
- 测试类创建:ExtensionMethodTest、嵌套类FillWith
- 执行@BeforeEach注解的方法
- 执行@Test或@ParameterizedTest注解的方法
- 执行@AfterEach注解的方法
- 测试类被丢弃
- 执行另一个测试方法时,从第二步开始再执行
- 执行被@AfterAll注解的静态方法
所以重点是
- 执行每个测试方法,都会重新创建测试类实例。而不是多个测试方法共用一个实例
- 基于上面的原因,@BeforeAll、@AfterAll注解的方法只能在类加载期间执行,即只能注解到静态方法上。这也解释了为什么非静态嵌套类中无法使用@BeforeAll,因为它无法定义静态方法呀
理解单元测试:单元测试的基本要求之一是测试case之间相互不影响,测试方法可能使用了测试类中定义的变量,为了保证不受其它使用该变量的测试方法的影响,最好的方式就是为该方法专门创建一个测试类实例。这就是JUnit的默认行为,一定要理解。
也十分推荐这么做,这才是真的单元测试。
对于那些多个测试方法确实需要共享一个测试类实例的情况,JUnit也提供支持。使用时要慎重,此时@BeforeAll的行为也会改变。
1
2
3 >
> class ExtensionMethodTest {
>
嵌套类
JUnit提供嵌套类的支持,是为了提供给用户更好的测试之间组关系的支持。
上例中,对每个待测方法,都有多个case需要执行,为了将二者分为两组,我为每个待测方法建立了一个嵌套对象,测试case作为嵌套对象的方法,这样在嵌套类上添加公共的说明,得到的测试报告也更加层次化。
可以想见,如果没有这样的支持,我需要在每个方法的显示名中加上说明,多么麻烦。
参数化测试
利器之二,最典型的应用场景就是针对各种不同输入的测试,参考上例ExtensionMethodTest.ParseOssObjectKey.just test()
,特点
使用注解@ParameterizedTest,可以通过name指定测试名称。name指定的字符串中有几个可以使用的占位符
Placeholder Description {displayName}
方法的名称 {index}
当前调用的参数在参数列表中的索引 {arguments}
完整的测试方法参数值,逗号分隔 {argumentsWithNames}
完整的测试方法的参数名和参数值,格式 key1=value1,key2=value2… {0}
,{1}
, …具体的参数 有定义的现成名称可以使用,如:
ParameterizedTest#DISPLAY_NAME_PLACEHOLDER
必须和数据源注解一起使用,支持的注解源(可以叠加使用)
@ValueSource:最常用,单个值的列表
@NullAndEmptySource、@NullSource。。。:最常用,null或空串
@EnumSource
@MethodSource:数据来源于一个方法
一个例子如下,重点是方法要是静态的(除非LifeCycle.PER_CLASS);返回Arguments的集合类型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20// 定义
fun provideLegalResource(): List<Arguments> {
return PathMatchingResourcePatternResolver().getResources(LEGAL_RESOURCE_PATTERN).map { resource ->
resource.file.name to publicObjectMapper.readTree(resource.file)
}.map { (fileName, mockResources) ->
mockResources.map { mockResource ->
val resourceType = fileName.removeSuffix(RESOURCE_FILE_SUFFIX)
val comment = (mockResource as ObjectNode).remove(COMMENT_KEY).asText()
val content = """{"resources": [${mockResource.toJsonString()}]}"""
// 上面都忽略,这才是关键
Arguments.of(resourceType, comment, content)
}
}.flatten()
}
// 使用
fun `200 when legal resource`(type: String, comment: String, content: String) {@CsvSource:以CSV的方式提供多个值
@CsvFileSource
@ArgumentsSource:数据源于一个ArgumentsProvider,这个其实是最通用的方法,上面的所有注解都是用ArgumentsProvider实现的,比如下面这个
1
2
3(NullArgumentsProvider.class)
public NullSource {
}
构造器和方法的注入
注意上面的beforeEach()方法的参数TestInfo,之所以方法执行时能够获得这个参数,是因为JUnit执行了注入操作。
JUnit支持向测试类的构造器和所有成员方法执行注入操作,对应的API是ParameterResolver
。默认实现有三个,有需要可以自己增加
TestInfoParameterResolver
RepetitionInfoParameterResolver
TestReporterParameterResolver
测试的继承
如果有多个类都有相同的测试前置操作、测试case,可以将这些内容抽取成为一个接口。JUnit的注解效果是可以继承的,这是个利器。合理使用。
举例:controller层的单元测试中,接口鉴权是公共的case,就很适合抽出来,每个接口都实现它。
更多内容请参考JUnit用户手册,值得探究的内容
测试顺序
测试方法之间默认没有顺序,但可通过添加@Order的方式声明顺序
并行执行
JUnit默认使用一个线程执行所有测试,我们可以指定多个以提升测试执行速度,不过要注意并发问题
条件测试
测试case在条件满足的情况下才执行
测试模板和动态测试
当需要动态生成测试方法时,不妨考虑考虑它们
Mockk
单元测试不像集成测试,”单元“二字是关键,一次只测试单个逻辑。其它级联的方法调用,都可以通过mock解决。与之对应的两个概念
- mock:完全伪造目标,目标可以是对象、静态方法、kotlin的object等
- spy:在现有的目标上进行mock
如果用Kotlin,mockk一定是个很好的尝试。相对于Mockito,MockK功能全面,支持DSL,书写流畅简单。下面通过一些场景介绍。
基本使用方法
MockK的完整使用方法包括
- 声明mock对象
- mock对象的行为
- 执行被测方法
- 验证结果、行为、过程参数
如果使用JUnit5,MockK提供MockkExtension,同MockitoExtension,Mockito中对应的@Mock、@Spy、@InjectMocks分别变成@MockK、@SpyK、@InjectMockKs。一个典型的例子
1 |
|
解释
miscService需要凭空mock,mediaProperties需要在提供的对象基础上mock
mediaService的创建依赖于miscService和mediaProperties
除了MockKExtension,也可以直接使用
MockKAnnotations.init(this::class)
来使其生效本例测试的是某个参数,使用slot()方法抓取该参数
默认行为
Mockito中,如果不对mock对象的行为做预设,行为默认返回对应返回类型的空值。MockK略有不同,不提供默认行为的mock,但可手动选择开启。
- @RelaxedMockK : 备注接的对象带有默认值
- @MockK(relaxed = true):同上
- mockk<MyObject>(relaxed = true):手动mock出来的MyObject对象行为带有默认值
mock单例对象
1 | object RequestContext { |
值得一提的是,mock枚举对象也是通过mockkObject完成,毕竟,每个枚举项就是一个单例。
mock静态方法
1 | object RequestContext { |
mock构造方法
比如下面这个,MembershipLevelStrategySelector创建,然后调用select()方法,我想要mock select()方法的行为,就要mockMembershipLevelStrategySelector的构造方法
1 | fun doRedeem(currentUser: Int, currentAward: InvitationAwardModel) { |
于是mock这样写
1 | mockkConstructor(MembershipLevelStrategySelector::class) |
与SpringBoot集成
有springmock项目为MockK集成到SpringBoot中提供支持,主要是支持@MockKBean注解啦。
其它
还有功能如下,不再详述,详情参考官方手册
- mock链式调用,即一次性mock
a.method1().method2()
,而不需要单独mock - 带有层次结构的mock
- 带有顺序的verify
- verify某个方法未被调用(很有用)
- 自定义answer等
AssertK
与AssertJ对应的,是AssertK。下面展示简单用法,具体参考官方手册
常规
1 | assertThat(person.name).isEqualTo("Hello") |
错误判断
1 | assertThat { throw Exception("error") }.isFailure().hasMessage("wrong") |
个人认为断言库的两个关键点是使用的简单性和报错信息的展示,AssertK做的都还不错。
如果觉得不够用,也有自定义断言可选。
Spring Boot Test
SpringBoot的test模块,默认包含的测试工具JUnit5、Mockito、AssertJ。相对于此,额外的功能是提供Spring上下文。
@SpringBootTest
在测试类上添加此注解是最为简单的方式。它做了如下几件事
通过SpringApplication创建ApplicationContext
默认情况下它不会启动server。但是可以通过webEnvironment指定环境,默认为MOCK
MOCK:创建一个web类型的ApplicationContext、一个web类型的Environment。SpringBoot内嵌的Server不会启动。如果类路径中没有web相关的类可提供。则回滚创建一个非web的ApplicationContext。
可以结合@AutoConfigureMockMvc和@AutoConfigWebTestClient使用。前者提供一个服务mock,后者提供一个客户端mock终端
RANDOM_PORT:创建WebApplicationContext,创建真实的Server,内嵌Server被启动,端口随机
DEFINED_PORT:同上,只不过端口跟随配置文件
NONE:创建普通的ApplicationContext
当Spring MVC、Spring Webflux任何一个被检测到,就创建对应的web环境。如果都存在,则创建MVC环境。此时想要使用webflux,只能@SpringBootTest(properties = “spring.main.web-application-type=reactive”)
可见,@SpringBootTest非常重,会扫描并加载所有Bean。但实际我们往往只会测试一部分内容,对此Spring提供了部分装载的功能,相当于@SpringBootTest的子集。它基于Spring的自动配置机制,现有支持可在这里查看。
依据实际使用的经验,Spring提供的测试机制并不友好,往往过于复杂,所以能不用就不用。相反,它更适合集成测试时使用,而不是单元测试。
@MockBean和@SpyBean
被此二者注解的属性,会使用Mockito生成一个mock对象,然后注入到容器中,其他地方可以自由注入。以@MockBean为例,它有如下特性
使用@SpringBootTest时,该功能默认开启。其它情况使用时,需要手动开启。添加两个监听器
1
2
3
class MyTests它定义的是Mockito的Mock
每个test方法结束后,被Mock的Bean会被reset
例子如下:Reverser使用真实对象;RemoteService使用Mock出来的Bean(覆盖原对象中的Bean)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class MyTests {
private Reverser reverser;
private RemoteService remoteService;
void exampleTest() {
given(this.remoteService.getValue()).willReturn("spring");
String reverse = this.reverser.getReverseValue(); // Calls injected RemoteService
assertThat(reverse).isEqualTo("gnirps");
}
}
前文说过,使用MockK后,换成@MockKBean,由springmock库提供支持
MockMvc
MockMvc是个好东西,与之相对的,是端到端测试(即服务启动,然后使用客户端手动访问)。使用它能够很好滴对Spring MVC构建的端点进行测试,同时支持Kotlin DSL,一个简单的例子
1 |
|
DSL的使用也相当简单,整体而言包括两个
- 填充请求参数
- 请求方式:get、post,或者中性的perform
- 请求路径
- 请求参数:头部、参数、body
- 填充预期内容
- 状态码
- 响应结果:头部、body等
通过代码提示,也能知道,有三种类型的API调用
- andReturn():返回执行结果,以MvcResult封装
- andExpect{}:DSL,填充一些断言
- andDo{}:DSL,这里可以做一些中性的实行,比如打印出响应结果。
这是Spring构建的Web应用的测试利器
但正如Spring手册所分类的那样,其实这玩意儿被分类在集成测试的
一点想法
想法1:眼高手低。收集资料时想着有好多东西可以写;写的时候又发现如果要把那些都写完,衍生出来的知识太多了,根本无暇顾及。于是拖延,但一想不行,要继续写呀,于是一减再减,成了现在看到的这个样子,不甚满意。但总好过没有。
想法2:写这类学习的文章,还是要趁新鲜。一些内容,刚看到时会有惊艳的感觉,觉得值得一写。习惯之后又觉得理所当然,有什么好写的。殊不知,数月后,对那些认为理所当然的内容,我又恢复成萌新的状态。又要从头开始学习,到时连个回顾的纲要都没有。或许从另一面证明了:写这玩意儿,叫做沉淀🤔